Skip to content

perf(render): synchronized output (DEC 2026) across all frame renders - #149

Merged
mparrett merged 1 commit into
mainfrom
perf/render-sync-output
Jul 23, 2026
Merged

perf(render): synchronized output (DEC 2026) across all frame renders#149
mparrett merged 1 commit into
mainfrom
perf/render-sync-output

Conversation

@mparrett

@mparrett mparrett commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator

What

Wraps every full-screen frame render in DEC private mode 2026 (begin/end synchronized update). The terminal holds the previous frame while we emit, then presents the whole batch at once, so a player never sees a half-drawn frame.

Reference + compat notes: https://github.com/contour-terminal/vt-extensions/blob/master/synchronized-output.md

Stacked on the appearance-diff PR (perf/render-appearance-diff, #146); review/merge that first. It's additive: #145/#146 cut how much we send, this makes what we send land in one piece. Both wins compose.

Why

A render pass emits a lot of escapes. If the terminal repaints partway through, the result is tearing: part of the screen at the new state, part still at the old. It's worst on the frames that repaint in succession — a map scroll, VFX, the pulsing death screen — and most visible on xterm.js (the web build), where a pan clearly tore.

The trap is thinking only those repeating frames tear. A single full-screen paint is itself a burst of escapes, and the terminal can composite it mid-draw — so a one-shot modal that clears and redraws a full inventory can also show a half-built frame. Every full-screen render is a candidate, so every one gets wrapped.

Coverage

The sync helpers live in xsofy.screenfx — the shared screen-effects namespace that both render and title already depend on and that reaches into neither — so every full-screen emitter shares one implementation rather than render owning it:

  • Game frames (xsofy.render): render-full, render-dirty, animate-vfx! (per frame), render-death-screen (animated — it pulses per frame through the ui run-loop), and the six modal screens (render-inventory-screen, render-messages-screen, render-help-screen, render-quick-menu, render-rune-codex, render-inscribe-screen)
  • Boot screens (xsofy.title): the animated title (draw-frame) and the descent screen (per frame)
  • screenfx/clear-screen — a full-screen paint on its own

render-vfx-frame no longer flushes on its own; the animation loop presents each frame. Only render-hazard-prompt stays on a plain flush — it's a one-line overlay, not a frame. A sync-begin! opens each frame; a flush-frame! (end-sync then flush) closes it.

Also fixes an unpaired begin: the final vfx frame opened a synchronized update but closed with a bare flush, leaving the mode set until the terminal's own timeout.

Design notes

  • Boolean mode, not a nesting counter. A single end-sync closes it, so only top-level frame entries are wrapped, never their inner helpers, and animate-vfx! gets one begin/end pair per frame rather than one around the whole loop.
  • No feature detection. The DECRQM probe (CSI ?2026$p) needs a fragile async read at boot, and unsupported terminals ignore the sequences, so worst case is a couple of wasted bytes per frame. sync-output? (default true) is a source opt-out for a terminal that composites 2026 poorly; the web target wants it on.
  • One caveat: a frame that throws before end-sync leaves the mode on, but the terminal's own 2026 timeout releases it, and a throw in the render path crashes to shutdown anyway.

Safety

Render-only, with no effect on the seed, action log, or world state, so determinism and replay are unaffected. The change is confined to frame boundaries; the per-cell render path is untouched, so the golden tests that drive render-map-tile see no synchronization bytes. Verified live: every frame emits exactly one balanced begin/end pair (equal counts across moves, all modal screens, and combat), with no unpaired begin that could freeze the display. Escape-sequence guard tests and the existing suite pass.

@mparrett mparrett added the perf-render Render emit performance (render.lg) label Jul 8, 2026
@mparrett mparrett mentioned this pull request Jul 8, 2026
6 tasks
@mparrett
mparrett force-pushed the perf/render-sync-output branch from c266f71 to 790c265 Compare July 8, 2026 19:53
@mparrett
mparrett changed the base branch from perf/render-appearance-diff to main July 8, 2026 19:53
@mparrett mparrett changed the title perf(render): wrap game render passes in synchronized output (DEC 2026) perf(render): synchronized output (DEC 2026) across all frame renders Jul 8, 2026
mparrett added a commit that referenced this pull request Jul 8, 2026
Reauthored additively on top of the appearance-diff stack (#146), replacing
the earlier #149 which was branched from main and rewrote render.lg back to
direct-emit — that would have reverted the pen + appearance buffer. This keeps
both wins: fewer escapes (pen/diff) AND atomic frames (sync).

Wraps the frames that repaint in succession — render-full, render-dirty, and
animate-vfx! (one BSU/ESU pair per animation frame; render-vfx-frame no longer
flushes, the loop presents). One-shot modal screens stay on a plain flush:
nothing streams after them, so there is nothing to tear. Render-only, so
determinism and replay are untouched; the golden tests drive render-map-tile
and don't see the frame-level sync bytes.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@mparrett
mparrett force-pushed the perf/render-sync-output branch from 790c265 to 731167b Compare July 8, 2026 20:16
@mparrett
mparrett changed the base branch from main to perf/render-appearance-diff July 8, 2026 20:17
@mparrett mparrett changed the title perf(render): synchronized output (DEC 2026) across all frame renders perf(render): synchronized output (DEC 2026) on the game's animated frames Jul 8, 2026
@mparrett
mparrett marked this pull request as ready for review July 8, 2026 21:19
@mparrett
mparrett force-pushed the perf/render-sync-output branch from 731167b to 426a0af Compare July 9, 2026 00:50
@mparrett mparrett changed the title perf(render): synchronized output (DEC 2026) on the game's animated frames perf(render): synchronized output (DEC 2026) across all frame renders Jul 9, 2026
@mparrett

mparrett commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator Author

Reviews complete:

All three PR heads are mergeable and their CI test runs are green. The stack wiring is correct: #145#146#149. After merging each lower PR, retarget/rebase the next PR onto main before merging it.

@mparrett
mparrett force-pushed the perf/render-sync-output branch from 426a0af to 0993a11 Compare July 9, 2026 20:58
@mparrett

mparrett commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator Author

Review is valid - only xsofy.render was wrapped.

Fixed by hoisting the sync helpers into xsofy.screenfx (the shared screen-effects ns that render and title already depend on and that reaches into neither), exactly as you suggested. They're now used at every full-screen emitter:

  • the animated title (title/draw-frame) and the descent screen — one begin/end pair per frame
  • screenfx/clear-screen — a full-screen paint on its own
  • plus the render.lg frames from before

One bonus fix en route: the final vfx frame opened a synchronized update but closed with a bare term/flush, leaving mode 2026 set until the terminal's timeout at the end of every animation. Now paired.

Verified across a full boot-to-game session with the title and descent animations included: begin and end counts match exactly (75/75), strictly alternating, no unpaired begin. Full suite green (288 tests, 2745 assertions). Force-pushed — new head 0993a11.

@mparrett mparrett added the deploy-preview Publish a gh-pages WASM preview for this PR label Jul 9, 2026
@github-actions

github-actions Bot commented Jul 9, 2026

Copy link
Copy Markdown
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-07-23 19:19 UTC

@mparrett
mparrett requested review from nnunley and nooga July 13, 2026 00:29
@mparrett
mparrett force-pushed the perf/render-appearance-diff branch from 7bd4e08 to 933504c Compare July 20, 2026 18:45
@mparrett
mparrett force-pushed the perf/render-sync-output branch from 0993a11 to 36ced34 Compare July 20, 2026 20:45
@mparrett
mparrett changed the base branch from perf/render-appearance-diff to main July 20, 2026 20:45
@mparrett
mparrett force-pushed the perf/render-sync-output branch from 36ced34 to 99d84e0 Compare July 21, 2026 03:01
A render pass emits many escapes; without synchronization the terminal can
repaint mid-stream and show a half-drawn frame — tearing. It's worst on frames
that repaint in succession (map scroll, animation, the pulsing title/descent/
death screens) and most visible on xterm.js, where a pan clearly tore. A single
full-screen paint is itself a burst of escapes that can be caught mid-draw, so
every full-screen render benefits. Wrap each in DEC private mode 2026: the
terminal holds the previous frame while we emit, then presents the batch at once.

The sync helpers live in xsofy.screenfx (the shared screen-effects ns that both
render and title already depend on, and which reaches into neither), so every
full-screen emitter shares one implementation:
  - game frames — render-full, render-dirty, animate-vfx! (per frame),
    render-death-screen, and the six modal screens (inventory, messages, help,
    quick-menu, rune-codex, inscribe)
  - the animated title (title/draw-frame) and descent screen (per frame)
  - screenfx/clear-screen (a full-screen paint on its own)
Only render-hazard-prompt stays on a plain flush — a one-line overlay, not a
frame. A sync-begin! opens each frame and a flush-frame! (ESU + flush) closes it.

Also fixes an unpaired begin: the final vfx frame opened a synchronized update
but closed with a bare flush, leaving the mode on until the terminal's timeout.

Design: mode 2026 is a boolean, not a nesting counter, so only top-level frames
are wrapped (never inner helpers) and multi-frame animations get one pair per
frame. No feature detection (the DECRQM probe is fragile; unsupported terminals
ignore the sequences). sync-output? (default true) is a source opt-out. Render-
only, so determinism/replay are untouched. Verified live: every frame emits one
balanced begin/end pair (BSU == ESU across boot animations, moves, all modal
screens, and combat).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@mparrett
mparrett force-pushed the perf/render-sync-output branch from 99d84e0 to 735e277 Compare July 23, 2026 00:37
@mparrett

Copy link
Copy Markdown
Collaborator Author

👍 Re-reviewed after the rebase — the earlier concern is fully addressed. The sync helpers no longer live in render; they moved to xsofy.screenfx (sync-begin! / flush-frame!, plus the begin-sync/end-sync constants), which is the shared screen-effects ns both render and title already depend on. Every full-screen emitter now goes through that one implementation:

  • xsofy.title/draw-frame (animated title) — wrapped with sfx/sync-begin!sfx/flush-frame!.
  • Animated descent screen — the per-frame dotimes loop is now wrapped per frame, not left on a bare term/flush.
  • screenfx/clear-screen — wrapped; the only remaining term/flush in that file is the one inside flush-frame! itself.

So the three paths called out before (title, descent, clear-screen) no longer emit large frames with a plain flush. Grepped title.lg / screenfx.lg to confirm there are no stray unwrapped frame flushes left.

Rebased onto latest main so the diff is now a single clean commit (#145/#146 already merged; picked up #170/#171). Verified locally: compile smoke passes, and the full suite is green — 305 tests / 2801 assertions, 0 failures — including the new escape-guard tests that lock the exact CSI ?2026 h/l sequences.

LGTM.

@mparrett

mparrett commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator Author

@nnunley Double checked, this one is now rebased and ready for a final look.

@nnunley nnunley left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So. I think having something like (with-frame ...) that controls the boundaries of the sync, with a (finally...) block that completes the action might avoid having a stack of accidentally nested syncs, or dangling syncs.

@mparrett

Copy link
Copy Markdown
Collaborator Author

Follow-up on the earlier suggestion — the (with-frame ...) idea (a try/finally that guarantees the sync closes even if a frame throws, so begin/end can't drift or dangle) is implemented in #174, stacked on this branch. Kept it separate so it doesn't reset the review here; the diff there is just the refactor on top of this PR.

@mparrett
mparrett requested a review from nnunley July 23, 2026 14:32
@mparrett

Copy link
Copy Markdown
Collaborator Author

@nnunley Thanks again for the nudge, it's definitely better. If you're good with it, I'll merge this one after re-approval, and we can continue the discussion and land #174 - curious if you lean a certain way on whether to explicitly handle nesting/depth. (I think we probably should but there are nuances)

@mparrett
mparrett merged commit 39b51f2 into main Jul 23, 2026
4 checks passed
@mparrett
mparrett deleted the perf/render-sync-output branch July 23, 2026 19:19
@mparrett
mparrett restored the perf/render-sync-output branch July 23, 2026 19:22
mparrett added a commit that referenced this pull request Jul 23, 2026
…es (#174)

Replaces the hand-written (sfx/sync-begin!) ... (sfx/flush-frame!) pairs
at every full-screen frame boundary with a single macro:

  (defmacro with-frame [& body]
    `(do (xsofy.screenfx/sync-begin!)
         (try ~@Body
              (finally (xsofy.screenfx/flush-frame!)))))

Every frame emitter becomes (sfx/with-frame ...): render-full,
render-dirty, animate-vfx!, the six modal screens, screenfx/clear-screen,
and title's draw-frame + descent loop.

Addresses the review note on #149: a with-frame with a finally makes the
sync boundary structural instead of a matched pair a future edit can
split. #149's own design note admitted a frame that throws before
end-sync "leaves the mode on, but the terminal's own 2026 timeout
releases it" -- the finally now closes it immediately, no reliance on
that timeout, and begin/end can't drift apart since they're generated
as one form.

Not nesting-safe, by design: mode 2026 is a boolean, so an inner
with-frame's finally would close the outer frame early. Preserves
#149's "wrap only top-level frames" discipline rather than changing it;
documented in the macro's docstring. commit-camera! hoisted just past
the frame boundary in render-full/render-dirty (state-only, no terminal
output, behavior-preserving).

Rebased onto main after #149 landed as a squash merge, which broke the
branch's ancestry and required a manual conflict resolution (cherry-pick
+ reapply) -- verified byte-identical in diff size to the original,
full suite re-run clean (307 tests / 2803 assertions, 0 failures).

Co-authored-by: Cursor <cursoragent@cursor.com>
@mparrett
mparrett deleted the perf/render-sync-output branch July 28, 2026 13:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

deploy-preview Publish a gh-pages WASM preview for this PR perf-render Render emit performance (render.lg)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants